/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is dual-licensed under either the MIT license found in the
 * LICENSE-MIT file in the root directory of this source tree or the Apache
 * License, Version 2.0 found in the LICENSE-APACHE file in the root directory
 * of this source tree. You may select, at your option, one of the
 * above-listed licenses.
 */

package com.facebook.buck.android.resources.strings;

import com.facebook.buck.android.resources.strings.StringResources.Gender;
import com.facebook.buck.core.filesystems.AbsPath;
import com.facebook.buck.io.filesystem.impl.ProjectFilesystemUtils;
import com.facebook.buck.io.pathformat.PathFormatter;
import com.facebook.buck.util.xml.XmlDomParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Maps;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

/**
 * This takes a list of string resource files (strings.xml), groups them by locales, and for each
 * locale generates a file with all the string resources for that locale. Strings.xml files without
 * a resource qualifier are mapped to the "en" locale.
 *
 * <p>A typical strings.xml file looks like:
 *
 * <pre>{@code
 * <?xml version="1.0" encoding="utf-8"?>
 * <resources>
 *   <string name="resource_name1">I am a string.</string>
 *   <string name="resource_name2">I am another string.</string>
 *   <plurals name="time_hours_ago">
 *     <item quantity="one">1 minute ago</item>
 *     <item quantity="other">%d minutes ago</item>
 *   </plurals>
 *   <string-array name="logging_levels">
 *     <item>Default</item>
 *     <item>Verbose</item>
 *     <item>Debug</item>
 *   </string-array>
 * </resources>
 *
 * }</pre>
 *
 * <p>For more information on the xml file format, refer to: <a
 * href="http://developer.android.com/guide/topics/resources/string-resource.html">String Resources
 * - Android Developers </a>
 *
 * <p>So for each supported locale in a project, this step goes through all such xml files for that
 * locale, and builds a map of resource name to resource value, where resource value is either:
 *
 * <ol>
 *   <li>a string
 *   <li>a map of plurals
 *   <li>a list of strings
 * </ol>
 *
 * and dumps this map into the output file. See {@link StringResources} for the file format.
 */
public class CompileStrings {

  private static final String ENGLISH_STRING_PATH_SUFFIX = "res/values/strings.xml";
  private static final String ENGLISH_LOCALE = "en";
  private static final String FEMALE_SUFFIX = "_f1gender";
  private static final String MALE_SUFFIX = "_m2gender";
  private static final int FEMALE_SUFFIX_LENGTH = FEMALE_SUFFIX.length();
  private static final int MALE_SUFFIX_LENGTH = MALE_SUFFIX.length();

  @VisibleForTesting
  public static final Pattern NON_ENGLISH_STRING_FILE_PATTERN =
      Pattern.compile(
          ".*res/values-(?:b\\+)*([a-z]{2})(?:-r([A-Z]{2}))*(?:\\+[a-zA-Z]{4})*/strings.xml");

  @VisibleForTesting
  public static final Pattern R_DOT_TXT_STRING_RESOURCE_PATTERN =
      Pattern.compile("^int (string|plurals|array) (\\w+) 0x([0-9a-f]+)$");

  /**
   * The pattern to be bypassed if we see mcc qualifier in the path. See more context in:
   * https://fb.workplace.com/groups/i18n.QandA/permalink/4066577280057523/
   */
  private static final Pattern BYPASSING_STRING_PATH_PATTERN =
      Pattern.compile(".*res/values-mcc[0-9]{3}/strings.xml");

  private final Map<String, String> regionSpecificToBaseLocaleMap = new HashMap<>();
  private final Map<String, Integer> stringResourceNameToIdMap = new HashMap<>();
  private final Map<String, Integer> pluralsResourceNameToIdMap = new HashMap<>();
  private final Map<String, Integer> arrayResourceNameToIdMap = new HashMap<>();

  /**
   * Note: The ordering of files in the input list determines which resource value ends up in the
   * output .fbstr file, in the event of multiple xml files of a locale sharing the same string
   * resource name - file that appears first in the list wins.
   *
   * @param stringFiles Set containing paths to strings.xml files matching {@link
   *     com.facebook.buck.android.resources.filter.GetStringsFiles#STRINGS_FILE_PATH}
   * @param rDotTxtFile Path to the R.txt file generated by aapt.
   * @param pathBuilder Builds a path to store a .fbstr file at.
   */
  public void compileStrings(
      AbsPath root,
      ImmutableList<Path> stringFiles,
      Path rDotTxtFile,
      Function<String, Path> pathBuilder)
      throws IOException, SAXException {
    buildResourceNameToIdMap(
        root,
        rDotTxtFile,
        stringResourceNameToIdMap,
        pluralsResourceNameToIdMap,
        arrayResourceNameToIdMap);

    ImmutableMultimap<String, Path> filesByLocale = groupFilesByLocale(stringFiles);
    Map<String, StringResources> resourcesByLocale = new HashMap<>();
    for (String locale : filesByLocale.keySet()) {
      resourcesByLocale.put(locale, compileStringFiles(root, filesByLocale.get(locale)));
    }

    // Merge region specific locale resources with the corresponding base locale resources.
    //
    // For example, if there are separate string resources in an android project for locale
    // "es" and "es_US", when an application running on a device with locale set to "Spanish
    // (United States)" requests for a string, the Android runtime first looks for the string in
    // "es_US" set of resources, and if not found, returns the resource from the "es" set.
    // We merge these because we want the individual .fbstr files to be self contained for
    // simplicity except for "en" because the string is already in Android resources.
    for (String regionSpecificLocale : regionSpecificToBaseLocaleMap.keySet()) {
      String baseLocale = regionSpecificToBaseLocaleMap.get(regionSpecificLocale);
      if (!resourcesByLocale.containsKey(baseLocale) || ENGLISH_LOCALE.equals(baseLocale)) {
        continue;
      }

      resourcesByLocale.put(
          regionSpecificLocale,
          resourcesByLocale
              .get(regionSpecificLocale)
              .getMergedResources(resourcesByLocale.get(baseLocale)));
    }

    for (String locale : filesByLocale.keySet()) {
      ProjectFilesystemUtils.writeBytesToPath(
          root,
          Objects.requireNonNull(resourcesByLocale.get(locale)).getBinaryFileContent(),
          pathBuilder.apply(locale));
    }
  }

  /**
   * Groups a list of strings.xml files by locale. String files with no resource qualifier (eg.
   * values/strings.xml) are mapped to the "en" locale
   *
   * <p>eg. given the following list:
   *
   * <p>ImmutableList.of( Paths.get("one/res/values-es/strings.xml"),
   * Paths.get("two/res/values-es/strings.xml"), Paths.get("three/res/values-pt-rBR/strings.xml"),
   * Paths.get("four/res/values-pt-rPT/strings.xml"), Paths.get("five/res/values/strings.xml"));
   *
   * <p>returns:
   *
   * <p>ImmutableMap.of( "es", ImmutableList.of(Paths.get("one/res/values-es/strings.xml"),
   * Paths.get("two/res/values-es/strings.xml")), "pt_BR",
   * ImmutableList.of(Paths.get("three/res/values-pt-rBR/strings.xml'), "pt_PT",
   * ImmutableList.of(Paths.get("four/res/values-pt-rPT/strings.xml"), "en",
   * ImmutableList.of(Paths.get("five/res/values/strings.xml")));
   */
  @VisibleForTesting
  public ImmutableMultimap<String, Path> groupFilesByLocale(ImmutableList<Path> files) {
    ImmutableMultimap.Builder<String, Path> localeToFiles = ImmutableMultimap.builder();

    for (Path filepath : files) {
      String path = PathFormatter.pathWithUnixSeparators(filepath);
      Matcher matcher = NON_ENGLISH_STRING_FILE_PATTERN.matcher(path);

      if (matcher.matches()) {
        String baseLocale = matcher.group(1);
        String country = matcher.group(2);
        String locale = country == null ? baseLocale : baseLocale + "_" + country;
        if (country != null && !regionSpecificToBaseLocaleMap.containsKey(locale)) {
          regionSpecificToBaseLocaleMap.put(locale, baseLocale);
        }

        localeToFiles.put(locale, filepath);
      } else {
        if (!path.endsWith(ENGLISH_STRING_PATH_SUFFIX)) {
          Matcher bypassingPathMatcher = BYPASSING_STRING_PATH_PATTERN.matcher(path);
          if (bypassingPathMatcher.matches()) {
            continue;
          }

          throw new RuntimeException(
              String.format(
                  "Invalid path passed to compile strings. Expected path to end with %s, got path"
                      + " %s.",
                  ENGLISH_STRING_PATH_SUFFIX, path));
        }

        localeToFiles.put(ENGLISH_LOCALE, filepath);
      }
    }

    return localeToFiles.build();
  }

  /**
   * Parses the R.txt file generated by aapt, looks for resources of type {@code string}, {@code
   * plurals} and {@code array}, and builds a map of resource names to their corresponding ids.
   */
  private static void buildResourceNameToIdMap(
      AbsPath root,
      Path pathToRDotTxtFile,
      Map<String, Integer> stringResourceNameToIdMap,
      Map<String, Integer> pluralsResourceNameToIdMap,
      Map<String, Integer> arrayResourceNameToIdMap)
      throws IOException {
    List<String> fileLines = ProjectFilesystemUtils.readLines(root, pathToRDotTxtFile);
    for (String line : fileLines) {
      Matcher matcher = R_DOT_TXT_STRING_RESOURCE_PATTERN.matcher(line);
      if (!matcher.matches()) {
        continue;
      }

      String type = matcher.group(1);
      String resourceName = matcher.group(2);
      Integer resourceId = Integer.parseInt(matcher.group(3), 16);
      switch (type) {
        case "string":
          stringResourceNameToIdMap.put(resourceName, resourceId);
          break;
        case "plurals":
          pluralsResourceNameToIdMap.put(resourceName, resourceId);
          break;
        case "array":
          arrayResourceNameToIdMap.put(resourceName, resourceId);
          break;
        default:
          throw new IllegalArgumentException("Invalid resource type: " + type);
      }
    }
  }

  private StringResources compileStringFiles(AbsPath root, Collection<Path> filepaths)
      throws IOException, SAXException {
    TreeMap<Integer, EnumMap<Gender, String>> stringsMap = new TreeMap<>();
    TreeMap<Integer, EnumMap<Gender, ImmutableMap<String, String>>> pluralsMap = new TreeMap<>();
    TreeMap<Integer, EnumMap<Gender, ImmutableList<String>>> arraysMap = new TreeMap<>();

    for (Path stringFilePath : filepaths) {
      Document dom =
          XmlDomParser.parse(ProjectFilesystemUtils.getPathForRelativePath(root, stringFilePath));

      NodeList stringNodes = dom.getElementsByTagName("string");
      scrapeStringNodes(stringNodes, stringsMap);

      NodeList pluralNodes = dom.getElementsByTagName("plurals");
      scrapePluralsNodes(pluralNodes, pluralsMap);

      NodeList arrayNodes = dom.getElementsByTagName("string-array");
      scrapeStringArrayNodes(arrayNodes, arraysMap);
    }

    return new StringResources(stringsMap, pluralsMap, arraysMap);
  }

  /**
   * Scrapes string resource names and values from the list of xml nodes passed and populates {@code
   * stringsMap}, ignoring resource names that are already present in the map.
   *
   * @param stringNodes A list of {@code <string></string>} nodes.
   * @param stringsMap Map from string resource id to its values.
   */
  @VisibleForTesting
  public void scrapeStringNodes(
      NodeList stringNodes, Map<Integer, EnumMap<Gender, String>> stringsMap) {
    for (int i = 0; i < stringNodes.getLength(); ++i) {
      Element element = (Element) stringNodes.item(i);
      String resourceName = element.getAttribute("name");
      Gender gender = getGender(element);

      Integer resId = getResourceId(resourceName, gender, stringResourceNameToIdMap);
      // Ignore a resource if R.txt does not contain an entry for it.
      if (resId == null) {
        continue;
      }

      EnumMap<Gender, String> genderMap = stringsMap.get(resId);
      if (genderMap == null) {
        genderMap = Maps.newEnumMap(Gender.class);
      } else if (genderMap.containsKey(gender)) { // Ignore a resource if it has already been found
        continue;
      }

      genderMap.put(gender, element.getTextContent());
      stringsMap.put(resId, genderMap);
    }
  }

  /** Similar to {@code scrapeStringNodes}, but for plurals nodes. */
  @VisibleForTesting
  public void scrapePluralsNodes(
      NodeList pluralNodes,
      Map<Integer, EnumMap<Gender, ImmutableMap<String, String>>> pluralsMap) {

    for (int i = 0; i < pluralNodes.getLength(); ++i) {
      Element element = (Element) pluralNodes.item(i);
      String resourceName = element.getAttribute("name");
      Gender gender = getGender(element);

      Integer resourceId = getResourceId(resourceName, gender, pluralsResourceNameToIdMap);
      // Ignore a resource if R.txt does not contain an entry for it.
      if (resourceId == null) {
        continue;
      }

      EnumMap<Gender, ImmutableMap<String, String>> genderMap = pluralsMap.get(resourceId);
      if (genderMap == null) {
        genderMap = Maps.newEnumMap(Gender.class);
      } else if (genderMap.containsKey(gender)) { // Ignore a resource if it has already been found
        continue;
      }

      ImmutableMap.Builder<String, String> quantityToStringBuilder = ImmutableMap.builder();

      NodeList itemNodes = element.getElementsByTagName("item");
      for (int j = 0; j < itemNodes.getLength(); ++j) {
        Node itemNode = itemNodes.item(j);
        String quantity = itemNode.getAttributes().getNamedItem("quantity").getNodeValue();
        quantityToStringBuilder.put(quantity, itemNode.getTextContent());
      }

      genderMap.put(gender, quantityToStringBuilder.build());
      pluralsMap.put(resourceId, genderMap);
    }
  }

  /** Similar to {@code scrapeStringNodes}, but for string array nodes. */
  @VisibleForTesting
  public void scrapeStringArrayNodes(
      NodeList arrayNodes, Map<Integer, EnumMap<Gender, ImmutableList<String>>> arraysMap) {

    for (int i = 0; i < arrayNodes.getLength(); ++i) {
      Element element = (Element) arrayNodes.item(i);
      String resourceName = element.getAttribute("name");
      Gender gender = getGender(element);

      Integer resourceId = getResourceId(resourceName, gender, arrayResourceNameToIdMap);
      // Ignore a resource if R.txt does not contain an entry for it.
      if (resourceId == null) {
        continue;
      }

      EnumMap<Gender, ImmutableList<String>> genderMap = arraysMap.get(resourceId);
      if (genderMap == null) {
        genderMap = Maps.newEnumMap(Gender.class);
      } else if (genderMap.containsKey(gender)) { // Ignore a resource if it has already been found
        continue;
      }

      ImmutableList.Builder<String> arrayValues = ImmutableList.builder();
      NodeList itemNodes = element.getElementsByTagName("item");
      if (itemNodes.getLength() == 0) {
        continue;
      }
      for (int j = 0; j < itemNodes.getLength(); ++j) {
        arrayValues.add(itemNodes.item(j).getTextContent());
      }

      genderMap.put(gender, arrayValues.build());
      arraysMap.put(resourceId, genderMap);
    }
  }

  /** Used in unit tests to inject the resource name to id map. */
  @VisibleForTesting
  public void addStringResourceNameToIdMap(Map<String, Integer> nameToIdMap) {
    stringResourceNameToIdMap.putAll(nameToIdMap);
  }

  @VisibleForTesting
  public void addPluralsResourceNameToIdMap(Map<String, Integer> nameToIdMap) {
    pluralsResourceNameToIdMap.putAll(nameToIdMap);
  }

  @VisibleForTesting
  public void addArrayResourceNameToIdMap(Map<String, Integer> nameToIdMap) {
    arrayResourceNameToIdMap.putAll(nameToIdMap);
  }

  @Nullable
  private static Integer getResourceId(
      String name, Gender gender, Map<String, Integer> resourceToIdMap) {
    Integer resId = null;
    if (name.endsWith(FEMALE_SUFFIX) && gender.equals(Gender.female)) {
      resId = resourceToIdMap.get(name.substring(0, name.length() - FEMALE_SUFFIX_LENGTH));
    } else if (name.endsWith(MALE_SUFFIX) && gender.equals(Gender.male)) {
      resId = resourceToIdMap.get(name.substring(0, name.length() - MALE_SUFFIX_LENGTH));
    }

    if (resId == null) {
      resId = resourceToIdMap.get(name);
    }

    return resId;
  }

  /**
   * Returns the Gender present in the passed in element's attribute, defaults to unknown gender
   *
   * @param element the element for which gender attribute is to be determined
   * @return gender present in the element and unknown gender if not
   */
  private static Gender getGender(Element element) {
    Gender gender = Gender.unknown;
    boolean hasGender = element.hasAttribute("gender");
    if (hasGender) {
      gender = Gender.valueOf(element.getAttribute("gender"));
    }
    return gender;
  }
}
